Go back home
Making a blogging system with Phoenix and React [Part 1]

Making a blogging system with Phoenix and React [Part 1]

making-a-blogging-system-with-phoenix-and-react-[part-1]

Welcome to this second part of the series.

If you haven't, you might want to read the intro. It will give you a good overview on what's happening here.

PSA: Saving and rendering HTML should only be done if:

  • A. You are/ you trust the source

  • B. You sanitize the content before rendering it.

Today, we're diving straight in by following this todo-list :

  • Setting up Milkdown (core and plugins) (You are here)

  • Scaffold the blog posts

  • Deal with image uploads

  • Create the API routes and functionalities

For the sake of simplifying the examples, I'll scaffold a basic Phoenix (1.7) app with mix phx.new mkdn --no-dashboard --no-mailer --database sqlite3

Setting up Milkdown

I won't go into much depth when it comes to adding node packages to a Phoenix app when the Phoenix Guide will be better at it.
Instead I'll go ahead and install all the packages I need in my assets directory.

We need the following:

npm i @milkdown/core @milkdown/ctx @milkdown/plugin-clipboard @milkdown/plugin-listener @milkdown/preset-commonmark @milkdown/preset-gfm @milkdown/prose @milkdown/theme-nord

Okay that's a lot, but remember (from the intro) that Milkdown is bare-bones and requires plugins to add functionality.

Here's what we need each of them for :

First of all core, ctx and preset-commonmark are required to get anything out of Milkdown.

Next, plugin-listener will allow us to listen for change events on our Milkdown editor; preset-gfm (gfm stands for "Github Flavored Markup") gives us extra formatting, plugin-clipboard will enable us to copy paste our markdown from another program into our Milkdown editor and parse the text (I don't know about you but I have a fear of hitting <kbd>ctrl+w</kbd> in the browser and losing all progress)

Finally, theme-nord will give us basic styling.

Okay, so. Our packages are installed, our Phoenix app is running. It is time to implement Milkdown.
For re-usability, it is going to live in a component. Since my usage demanded it, I modified the basic Phoenix core components textarea input to fit my needs.

In our app/lib/app_web/components/core_components.ex, we're finding the textarea input and changing the following:

#app/lib/app_web/components/core_components.ex
  def input(%{type: "textarea"} = assigns) do
    ~H"""
-    <div phx-feedback-for={@name}>
+    <div phx-feedback-for={@name} phx-hook="MarkdownEditor" id={@id <> "-wrapper"}>
          <.label for={@id}><%= @label %></.label>
-     <textarea ... />
    +     <div id={@id <> "-mkdown"}></div>
          <.error :for={msg <- @errors}><%= msg %></.error>
    </div>
    """
  end

We added a Phoenix hook named "MarkdownEditor" to our div and gave it an id, deleted the old textarea tag and added a div with an id of id-mkdown. "For why?"
For the next step!

Let's create a markdown.js in our assets/js/ folder. This is where the logic of our editor lives. For now it needs to handle basic things such as creating an editor where it is needed.

# app/assets/js/markdown.js

//Markdown editor
import {
  Editor,
  rootCtx,
  defaultValueCtx,
} from "@milkdown/core";
import { commonmark } from "@milkdown/preset-commonmark";
import "@milkdown/theme-nord/style.css";
import { gfm } from "@milkdown/preset-gfm";
import { clipboard } from "@milkdown/plugin-clipboard";

/*
 * Creates a Milkdown editor.
 * @param {HTMLNode} - dom - the node or the selector of the element that needs to receive our editor
 * @param {string} - defaultValue - The defaultValue, to be injected in the editor. Defaults to a blank string
 */
function makeEditor(dom, defaultValue = "") {
  const MakeEditor = Editor.make()
    .config((ctx) => {
      ctx.set(rootCtx, dom);
      ctx.set(defaultValueCtx, defaultValue);
    })
    .use(commonmark)
    .use(gfm)
    .use(clipboard)
    .create();
}

export {makeEditor}


Here we define a function that will create a Milkdown editor on a DOM node, with a default value. This function is exported and used in our app.js

#app.js

// your other imports
import { makeEditor } from "./markdown";
// ... the rest of your code


let Hooks = {};
Hooks.MarkdownEditor = {
  mounted() {
    // We register a Milkdown editor for every textarea in our form.
    // We also instantiate a value if there is one.
    const mkdownId = this.el.children[2].id;

    makeEditor(document.querySelector(`#${mkdownId}`), "");
  },
};

We use the hook we defined in our component to tell our app.js what functions to run when our div is mounted.
For the moment, it creates a blank editor where a textarea should be. We can test this out by inserting a textarea in an existing page.

We're adding this snippet of code to our mkdn_web/controllers/page_html/home.html.heex:

<.input type="textarea" name="test" value="test"/>

The values we're giving the component do not matter, we just want to make sure our system works.

If you squint really hard, or if you use the inspector, you'll notice that the markdown is in fact here, it just has no style what-so-ever.
This is the issue that made me quit trying to get markdown to work when I first tried. I didn't look too much into it because I needed something that works like, yesterday.

So, where is the style ? After all we imported the CSS sheet and we told Markdown to use the nord theme. By all means it should show up, correct ? Wrong ! Since the default app that Phoenix gives you is bundled with Esbuild and Tailwind, any CSS you give it will get overwritten during asset deployment.
You can go have a look the stylesheet in mkdn/priv/static/assets/app.css and see how nothing gets shipped except what's in your mkdn/assets/css/app.css.

Yes, lazy voice in my head, we could copy and paste the entire nord theme into the entry app.css file, but that would not be elegant.

We could set the ignorePreflight to true in our tailwind.config.js but this will break your styling during development.
What I found to work the best is to simply tell tailwind to compile to its own tailwind.css file in the static assets folder, and add a link to it in our root.html.heex file.

Open your mkdn/config/config.exs file, and change the contents of your tailwind config:

#mkdn/config/config.ex

# Configure tailwind (the version is required)
config :tailwind,
  version: "3.4.3",
  mkdn: [
    args: ~w(
      --config=tailwind.config.js
      --input=css/tailwind.css
      --output=../priv/static/assets/tailwind.css
    ),
    cd: Path.expand("../assets", __DIR__)
  ]

Now either rename mkdn/assets/app.css to mkdn/assets/tailwind.css or create a new file called tailwind.css and copy paste the contents.
Esbuild will now correctly bundle your style in priv/static/assets/app.css and tailwind will keep to its tailwind.css file, for both input and output.

Now that we have basic styling (tailwind will still override most of them, for ex none of your h1, h2, h3 etc will be styled), let's at least make the input look like the rest of our inputs.

That's easily done by adding the same classes when creating the markdown editors in our makeEditor function in js/markdown.js.
Modify it to this :

function makeEditor(dom, defaultValue = "") {
  const MakeEditor = Editor.make()
    .config((ctx) => {
      ctx.set(rootCtx, dom);
      ctx.update(editorViewOptionsCtx, (prev) => ({
        ...prev,
        attributes: {
          class:
            "min-h-[128px] p-2 mt-2 block border w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 border-zinc-300 focus:border-zinc-400",
        },
      }));
      ctx.set(defaultValueCtx, defaultValue);
    })
    .use(commonmark)
    .use(gfm)
    .use(clipboard)
    .create();
}

These are the basic tailwind classes that Phoenix uses.

Good! Our markdown areas now look like our previous textareas. The next thing we need to do is actually make some blog posts.

We have done a lot so we'll take a quick break and scaffold our blog in the next post